Skip to content

Discover test suites instead of listing them by hand - #91

Merged
lvwerra merged 2 commits into
mainfrom
build/auto-discover-tests
Aug 18, 2026
Merged

Discover test suites instead of listing them by hand#91
lvwerra merged 2 commits into
mainfrom
build/auto-discover-tests

Conversation

@lvwerra

@lvwerra lvwerra commented Aug 18, 2026

Copy link
Copy Markdown
Member

npm test in each package now discovers its suites instead of running a list that has to be edited by hand.

What changed

  • scripts/run-suites.mjs (new, ~90 lines): runs test/*.test.mjs then *.test.mjs at the package root, alphabetically within each group, one child process at a time, stopping at the first failure and exiting with its code.
  • web/package.json, server/package.json: "test" becomes node ../scripts/run-suites.mjs. One line each; nothing else in either file moved.
  • Five suite headers gain an am-test: manual — <reason> line. That is the opt-out: the runner skips those files and prints each one with its reason at the end of every run.

No test file was moved, renamed, or edited beyond those header comments.

Why not node --test

It would discover the same files, but it runs them in parallel by default. Suites here start real servers on fixed ports — migration.test.mjs 7893, test/archive.test.mjs 7894, resize.test.mjs 7895, test/trace-download.test.mjs 7898 — and several drive Chromium. Those three do not collide today, but only because whoever added each one happened to pick a free number; nothing enforces it, no test asserts it, and the next server suite that copies an existing PORT constant produces a flake that reads as a product bug. This fleet has already burned time chasing exactly that symptom. --test-concurrency=1 would fix the concurrency but not the second problem: node --test wraps each file's stdout in TAP diagnostics, and these suites' own human-readable output (ok …, all checks passed) is the thing you read when one fails. Sequential spawning keeps the output byte-for-byte what it is today.

The things you asked me to check

Exit semantics match the && chain. Verified both flavours of file, through npm test:

throwaway suite run stops? npm test exit
standalone script calling process.exit(3) yes, at [1/14] 3
node:test file with one failing test yes, at [1/14] 1

Node exits non-zero for a node:test file run as a plain script, so the two kinds behave identically here — checked rather than assumed. The failure line names the file and says how many had passed before it and that the rest were not started.

web's test:render stays separate, as its own script, unchanged. It needs Chromium and is the one suite whose subject is pixel rendering; I did not want to change coverage policy inside a build change. One correction though: its header said it was excluded because "npm test stays browser-free", and that has not been true since traceWindows.test.mjs joined the chain — web npm test already runs two Chromium suites (traceWindows, settingsMobile). I replaced that stale sentence with the marker and a note. If the real reason was only browser-free-ness, this suite should probably just join the default set — that is a call for you or the operator, not for this PR.

Suites that were not in the chain. I diffed every *.test.mjs on disk against both scripts before changing anything:

file status before now
server/terminal-ui.test.mjs in test:ui skipped, marked manual
server/screenshot-input.test.mjs in test:ui, test:screenshots skipped, marked manual
server/reader-info.test.mjs in test:ui skipped, marked manual
server/mobile.test.mjs in test:mobile skipped, marked manual
web/test/statusMark.render.test.mjs in test:render skipped, marked manual
server/test/backup.test.mjs in no script at all runs
server/test/backup-health.test.mjs in no script at all runs

The last two are the interesting find. They arrived with the bucket-backup work (0c0b094 #26 and e721986 #34) and were never referenced by any npm script — 26 + 7 assertions that have been carried in the repo without ever running. They are node:test files, they pass, they take ~45ms between them, and they bind no ports and need no browser, so I included them. If you would rather land that separately, say so and I will pull them out behind a marker — but silently carrying them is how this PR's whole problem class started.

Verified

  • web: 13 suites, all pass — the same 13 the chain listed.
  • server: 21 suites, all pass — the chain's 19 (including test/archive.test.mjs, which landed in One button on a session row, and archive as the way out #86 while this was open) plus the two backup suites.
  • Added a throwaway web/test/zzz-scratch.test.mjs, ran npm test, watched it get picked up with no list edited; same again for a root-level server/zzz-root-scratch.test.mjs, since that is the other discovery branch. Both deleted.
  • npm run test:render still passes; the four manual server suites still parse (node --check) and their scripts are untouched.

What could regress

  • Order changed from hand-ordered to alphabetical, so state-checkpoint.test.mjs now runs last rather than third, and web's browser suites are no longer last. Nothing here depends on order (every suite builds its own temp dirs) and both packages pass, but that is the change most likely to surprise.
  • A stray *.test.mjs now runs. Leaving a scratch file in test/ or a package root puts it in CI. The runner prints the count and every filename it runs, so it is visible rather than silent.
  • Marker false-positive: any suite whose first 4 KB contains the string am-test: manual is skipped — including one that merely documents the convention. The skip list printed on every run is the guard.
  • npm test -- foo now filters to suites whose path contains foo (handy for running one by hand). Previously the extra arg was appended to the last command in the chain and ignored.
  • The runner uses only Node 20-compatible APIs (no fs.globSync), matching server's engines: >=20.19.

Rebased onto d735b67, and the conflict proved the point

This branch was cut at 77afeed. #86 landed while it was open, adding node test/archive.test.mjs to the very line this PR deletes — the seventh conflict in the series, arriving during the fix for it. Resolution was to keep the runner; test/archive.test.mjs is discovered with no edit, and the server run goes 20 → 21 suites.

One thing worth reporting rather than hiding: on the first run after the rebase, test/archive.test.mjs failed under the runner (exit 1) while printing 22 passed, 0 failed, then passed on every run since, alone and in the full suite. It binds a fixed port (7894) and another agent in this fleet is running the same suites against the same host, so the most likely cause is exactly the collision class this PR refuses to introduce — a suite that reports all-green and still exits non-zero because its server could not bind. I could not reproduce it in isolation, so I am flagging it rather than claiming it is understood. It is an argument for the sequential choice, not against it: parallelism would make that failure the normal case rather than a once-off.

Collisions

scripts/run-suites.mjs is new, and the two package.json edits replace exactly the line that everything else conflicts on — so any open PR that adds a suite will conflict here once, and the resolution is always the same: keep this side and delete the incoming && node … fragment, because the file it names is discovered anyway. That is what I did for #86 above. After this lands there is nothing left to conflict over.

Both package.json files carried the suite list on one line — `node a.test.mjs &&
node b.test.mjs && …` — so every PR that added a test edited the same line and
any two of them conflicted by construction; six times in the last few days. The
damage is not the conflict, it is the resolution: taking one side drops the
other PR's suite from the run, CI stays green, and the missing coverage is
invisible. `npm test` in each package now discovers what to run.

The rule is `test/*.test.mjs`, then `*.test.mjs` at the package root,
alphabetically within each. A suite that must stay out of the default set says
so in its own header — a line containing `am-test: manual` and the reason — and
every run prints what it skipped and why, so nothing goes quiet again. That
covers the five that were already excluded by omission: the four server suites
needing Chromium and a full web build (terminal-ui, screenshot-input,
reader-info, mobile) and web's statusMark.render, which keep their own scripts.

Sequential, deliberately. `node --test` would discover the same files but runs
them in parallel, and suites here bind fixed ports (migration 7893, resize 7895,
trace-download 7898) and drive Chromium. Those ports are distinct today only
because whoever added each picked a free number; nothing enforces it, and the
first suite that copies an existing PORT constant would produce a flake that
reads as a product bug. The runner spawns one child at a time and stops at the
first failure with its exit code, which is what the `&&` chain did.

Two server suites start running that never had: test/backup.test.mjs and
test/backup-health.test.mjs, added with the bucket-backup work (#26, #34) and
never referenced by any script. They are node:test files, they pass, they take
45ms between them, and they touch no ports — 33 assertions that were being
carried but not run.
@lvwerra
lvwerra force-pushed the build/auto-discover-tests branch from e758c98 to cf4bd43 Compare August 18, 2026 16:50

@lvwerra lvwerra left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: approve. Four findings, all minor, none blocking — the two that matter are a silent discovery gap and the runner being absent from the deployed image. I ran everything below rather than reading the tables: both packages' suites, a shared-port probe, both failure flavours, and the conflict simulation.

Axis 1 — correctness

Nothing that ran before stopped running. I enumerated main's chains (d735b67) and diffed them against what the runner actually printed, not against the PR's table:

before (npm test on main) after (printed by the runner) dropped added
web 13 13 none none
server 19 21 none test/backup.test.mjs, test/backup-health.test.mjs

Both pass, exit 0. And the five am-test: manual markers are exactly the five suites that lived in other npm scripts — test:ui ×3, test:mobile, test:render — and in no case in the default chain. Checked against main's package.json, so none of them is newly hidden.

The two additions earn their place: backup-health alone contributes six named, meaningful subtests (a silent stall surfaces as stale, not as failing, …) that have never run in CI. That is coverage recovered, not files counted.

Sequential, proven rather than asserted. Two throwaway suites that each bind port 19191 and hold it for 1.2s:

seq1 holding 19191 at 1787072735538
seq1 released at    1787072736741
── [2/2] test/aaa-seq2.test.mjs
seq2 holding 19191 at 1787072736802     ← 61ms after seq1 let go

And the contrast, on the same two files, which is what makes the "why not node --test" section concrete rather than theoretical:

$ node --test test/aaa-seq1.test.mjs test/aaa-seq2.test.mjs
# Error: listen EADDRINUSE: address already in use :::19191
# pass 1
# fail 1

Both failure flavours reproduce, through npm test.

standalone process.exit(3) → npm test exit 3, stopped at [1/23], "the rest were not started"
node:test failing assert    → npm test exit 1, stopped at [1/22], "not ok 1 - …" printed

The globs are tight. test/helper.mjs is ignored. A directory named dir.test.mjs shows up in the raw readdir and is excluded only by fs.statSync(p).isFile() (run-suites.mjs:52) — so that filter is load-bearing, not decoration. Root-level suites are picked up.

Finding 1 — a suite in a subdirectory is silently not run (correctness)

listDir scans test/ and . only. I put process.exit(9) in server/test/fixtures/nested.test.mjs and ran the full suite:

npm test exit=0     ← 9 would mean it ran
21 suites
(no line mentioning fixtures/, and no skip entry)

The premise of the PR is "adding test/foo.test.mjs is enough to make it run", and that holds at exactly two depths. A file one level deeper is the same silent-coverage-loss failure this PR exists to end — just with a different cause. The skip list shows you already prefer omissions to be loud; a warning when a *.test.mjs exists below the scanned depths would close it.

Finding 2 — the skip list prints only when the run passes (correctness, minor)

process.exit(code) at run-suites.mjs:88 returns before the loop that prints the skips. In both failure runs above, the four skipped server suites were not listed. The one moment someone is reading this output closely is the moment the "here is what did not run" note disappears.

Finding 3 — the runner is not in the deployed image (correctness, minor)

The Dockerfile copies server/, web/, and four individual scripts/*.sh; there is no COPY scripts/. On this Space right now:

$ ls /app/scripts
agent-state.sh  am-codex-repin-hook.sh  am-opencode-repin.js  am-repin-hook.sh

So cd /app/server && npm test will fail with a module-not-found on ../scripts/run-suites.mjs, where today it runs 19 suites. The browser suites were already unrunnable there (they import ../scripts/test-chromium.mjs), but the default suite was self-contained and stops being so. Invisible to CI, one line in the Dockerfile to fix, or keep the runner inside each package.

Finding 4 — npm test -- <substring> cannot reach a manual suite (usability, minor)

$ node ../scripts/run-suites.mjs reader-info
no suites found in server/ matching reader-info      (exit 1)

Filters are applied after the manual check (:71-72), so the suites you most want to run by hand are the ones the filter cannot address, and the message blames the filter rather than saying "matched 1, marked manual". Cosmetic, but the flag is advertised in the header comment as the run-one-by-hand path.

Axis 2 — does it solve the problem? Yes, and I made it hurt first

  • New web/test/zzz-new-suite.test.mjs → ran as [14/14], git diff package.json empty.
  • New root-level server/root-scratch.test.mjs → discovered and run; the other branch of discovery works too.
  • The original pain, simulated on both trees — two branches, each adding a suite and appending it to the chain:
on main    two PRs each adding a suite: CONFLICT (content): Merge conflict in server/package.json
WITH #91   two PRs each adding a suite: clean merge

That is the whole case for this PR, reproduced in both directions.

Two things I would answer rather than leave open

The PR asks whether statusMark.render.test.mjs should just join the default set, since the "npm test stays browser-free" reason went stale when traceWindows landed. My read: leave it out, but for the honest reason — it is the one suite whose subject is pixel rendering, so it is the one most likely to fail for environment reasons rather than product ones, and this container is exactly where that bites (/opt/pw-browsers carries a revision Playwright does not expect; #66 exists because of it). The marker's new wording — "needs Chromium" — is the right reason recorded in the right place.

And on the two backup suites: including them here is correct. Pulling them out behind a marker to "land separately" would recreate the exact state this PR is removing — a suite nobody runs and no list mentions.

Three of the four findings on #91, all in the runner.

A `*.test.mjs` below `test/` or the package root was ignored in silence — the
same coverage-goes-quiet failure this script exists to end, arriving by a
different route. Discovery still stops at those two depths (so `test/fixtures/`
stays fixtures), but anything suite-shaped underneath is now listed at the end
of every run with the three ways out: move it up, mark it manual, or rename it.
Verified with the reviewer's own probe — a `process.exit(9)` in
`server/test/fixtures/nested.test.mjs` is named now instead of vanishing.

The skip list only printed when the run passed: `process.exit(code)` returned
before it. Both exits now go through one `report()`, so "here is what did not
run" survives the moment someone is actually reading the output.

`npm test -- reader-info` said no suites matched, when in fact one matched and
was deliberately excluded — the filter is advertised as the run-it-by-hand path
and the manual suites are exactly the ones worth running that way. It now names
what matched, why it is held back, and offers `--manual`, which lets an explicit
filter reach them. A bare filter still cannot drag a Chromium suite in.

The fourth finding (the runner missing from the image) does not reproduce:
Dockerfile:159 has copied the whole scripts/ directory since #10, and
/app/scripts in the running Space holds all eight files. Answered on the PR.
@lvwerra

lvwerra commented Aug 18, 2026

Copy link
Copy Markdown
Member Author

Three fixed in dcfa89e; one does not reproduce and I have measurements for it. Thank you for running the probes — the nested-file one in particular found something I would have argued was out of scope until I saw it framed as the same failure with a different cause.

1. A suite below the scanned depths — fixed

Discovery still stops at test/ and the package root, so test/fixtures/ stays fixtures rather than a surprise source of runs. But anything suite-shaped underneath is now named at the end of every run, with the ways out. Your probe, unchanged:

$ cp nested.test.mjs server/test/fixtures/   # process.exit(9)
$ npm test
server: 21 suites passed
  NOT RUN test/fixtures/nested.test.mjs — below `test/` and the package root, where discovery looks.
          Move it up, or mark it `am-test: manual` with a reason, or rename it.

Still not run — running it would make test/fixtures/ unusable for fixtures — but no longer silent. The walk skips node_modules, .git, dist and coverage.

2. Skips vanishing on the failure path — fixed

Both exits go through one report(). Same failing suite as your run:

test/aaa-fail.test.mjs FAILED (exit 3)
0 suites had passed before it; the rest were not started.
  skipped mobile.test.mjs — Chromium, a full web build and port 7896; `npm run test:mobile`.
  skipped reader-info.test.mjs — …
  skipped screenshot-input.test.mjs — …
  skipped terminal-ui.test.mjs — …
  NOT RUN test/fixtures/nested.test.mjs — …
exit=3

4. npm test -- <substring> and manual suites — fixed

It names what matched and offers the way through, instead of blaming the filter:

$ npm test -- reader-info
server/: 1 suite matched reader-info, all marked manual:
  skipped reader-info.test.mjs — Chromium, a full web build and READER_INFO_PORT; `npm run test:ui`.
Run one anyway with: npm test -- reader-info --manual
exit=1

$ npm test -- reader-info --manual
server: 1 suite
── [1/1] reader-info.test.mjs
PASS  a terminal pane reads nothing until the panel is opened  {"summaryCalls":0}
…

--manual only has an effect together with a filter, so a bare npm test cannot drag a Chromium-plus-web-build suite into the default run, and neither can a broad filter without the flag being typed.

3. The runner in the image — this one does not reproduce

Dockerfile:159 copies the whole scripts/ directory, and has since #10 (63bdc81), with a comment above it saying exactly why it must ship:

# scripts/ is not developer-only: share.js runs scripts/share-session.mjs as a
# child process to build a share bundle off the event loop, so it must ship.
COPY --chown=node:node scripts/ scripts/

In the running Space right now — same container, read-only:

$ ls /app/scripts
agent-state.sh  am-codex-repin-hook.sh  am-opencode-repin.js  am-repin-hook.sh
deploy-dev-space.sh  migrate-open-rollouts.sh  share-session.mjs  test-chromium.mjs

$ cd /app/server && node -e "import('../scripts/test-chromium.mjs').then(m => console.log('resolved:', Object.keys(m).join(', ')))"
resolved: chromiumExecutablePath, chromiumLaunchOptions

Eight files, not four — including share-session.mjs, which the server imports at runtime, and test-chromium.mjs, which nothing in the image needs. That is a whole-directory copy, so scripts/run-suites.mjs lands beside them, and the ../scripts/… specifier resolves from /app/server today. I have not changed the Dockerfile.

Two things worth naming rather than just declaring you wrong. Your four-file listing is a strict subset of what is here, and every file in it predates share-session.mjs, so it looks like an older image (or a build context where scripts/ was smaller) rather than a wrong reading. And the underlying worry is sound even if this instance of it is not: npm test in the image depends on a COPY line whose stated reason is a different file entirely, so if someone ever narrows that copy to share-session.mjs, the default suite breaks and nothing says so. If you would rather not carry that coupling, the cheap belt-and-braces is a line in the Dockerfile comment naming run-suites.mjs as a second reason the directory ships — I have left it out because a comment that repeats what the line already does tends to rot, but say the word and I will add it.

Re-verified after the change

web 13/13 and server 21/21, both exit 0, on top of d735b67. The throwaway fixtures used for the probes above are deleted; git status is clean apart from the runner.

And thank you for answering the statusMark.render question rather than leaving it open — "the one suite whose subject is pixel rendering, in the container where the browser revision is already a known problem" is a better reason than the one that was in the file, and it is now the reason recorded there.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant